# encoding: utf-8
"""
@desc: 二分图推荐算法
@author: rannuo
@contact: rannuo1010@gmail.com
@time: 2019/4/23 20:57
@file: Prob.py
"""
import random
import time
import math
import matplotlib.pyplot as plt
import os


def read_file(file_path):
    """
    读取文件数据
    :param file_path: 源文件路径
    :return: 文件操作对象
    """
    # 文件不存在，直接返回
    if not os.path.exists(file_path):
        print("文件不存在")
        return None
    # 文件存在，打开文件
    fp = open(file_path, encoding='utf-8')
    return fp


def get_data(rating_file):
    """
    读取用户数据
    :param rating_file:
    :return:
    """
    # 调用自定义函数读取首行除外的文件数据
    fp = read_file(rating_file)
    if fp is None:
        return {}
    # 返回一个数据结构
    data = []
    # 按行遍历文件数据
    for line in fp:
        item = line.strip().split('::')
        # 跳过长度不符合要求的行
        if len(item) < 4:
            continue
        [user_id, item_id, rating, timestamp] = item
        # 跳过评分小于阈值（3）的行
        # if float(rating) < 3.0:
        #     continue
        data.append((int(user_id), int(item_id), float(rating), int(timestamp)))
    fp.close()
    print("读取用户数据完成！")
    return data


def split_data(data, M, k, seed):
    """
    划分训练集、测试集
    将用户行为数据集按照均匀分布随机分成M份，挑选一份作为测试集，将剩下的M-1份作为训练集
    :param data:源数据
    :param M:数据集划分份数
    :param k:一个任意的数字，用来随机筛选测试集和训练集
    :param seed:随机数种子，在seed一样的情况下，生成的随机序列是相同
    :return:train-训练集test-测试集
    """
    train = {}
    test = {}
    #    random.seed(seed)
    for user, item, rating, _ in data:
        if random.randint(0, M) == k:
            test.setdefault(user, {})
            test[user][item] = rating
        else:
            train.setdefault(user, {})
            train[user][item] = rating
    return train, test


def gen_graph(train):
    """
    获取用户——物品二分图
    :param train:
    :return: 二分图字典:1.用户→商品→评分是否大于3 .2.商品→用户→评分是否大于3
    """
    G = dict()
    for u, items in train.items():
        for i in items:
            value = 1
            if items[i] < 3.0:  # 评分小于3分置为0
                value = 0
            item_id = "item_" + str(i)
            if u not in G:
                G[u] = {}
            G[u][item_id] = value
            if item_id not in G:
                G[item_id] = {}
            G[item_id][u] = value
    return G


def get_item_kind():
    # 返回电影属性数目列表
    fp = open("../data/u.item", encoding='gb18030', errors='ignore')
    txt = []  # 对电影数据集按行读取
    for line in fp:
        txt += [line]
    item_nub = []  # 统计各属性

    for tt in txt:  # tt是txt里面一个每一个字符串，相当于每一个电影信息
        tk = []  # 接收属性编码向量
        tk_f = 0  # 对“|”计数
        for t in tt:  # t遍历字符串tt里面的每一个字符
            if t == '|':
                tk_f += 1
            if tk_f >= 5 and (t == '0' or t == '1'):  # 数据集中从第五个“|”开始就是电影属性编码
                tk += [eval(t)]  # eval 函数去掉字符串两边的引号，然后加到tk列表里面
        item_nub += [sum(tk)]
    return item_nub  # 电影的属性数列表


mov_info = get_item_kind()  # 获取电影信息


def sim(G1, G2):  # 计算用户1和用户2之间的相似性，使用jaccard相似性系数
    '''
    G1：用户1选过的物品的字典
    G2：用户2选过的物品的字典
    返回：两个用户的jaccard相似度
    '''
    it_count = 0  # 总计数（并集）
    share_count = 0  # 共有物品计数（交集）
    for it in G1:
        it_count += 1
        if it in G2:
            share_count += 1
        else:
            #            print(0)
            it_count += 1
    return share_count / it_count


def recommend(G, user, N, G_sim, nk1, nk2):
    target = user

    """
    推荐
    :param G:
    :param user:
    :param N:
    :return:
    """
    '''Step1------物品拥有的资源值扩散到用户'''
    user_res = {}
    for item in G[target]:  # 遍历目标用户选过的商品集合
        k_item = len(G[item])  # 物品的度
        # 根据用户选择过的物品，计算用户资源
        if eval(item[5:]) - 1 < len(mov_info):  # 如果商品在商品信息表里
            xaj = max(mov_info[eval(item[5:]) - 1], 1)  # 电影属性数
        else:
            xaj = 10  # 将电影属性数置为10

        for user in G[item]:  # 遍历选择过该物品的用户
            pre_value = user_res.setdefault(user, 0)
            # xaj是电影的属性数量，nk1是参数，调节属性数对推荐的影响
            user_res[user] = G[item][user] / (((xaj) ** nk1) * float(k_item)) + pre_value

    '''Step2 - 用户得到的资源值扩散到物品'''
    # 对能量做一个归一化
    # emax = max([user_res[i] for i in user_res])
    # for i in user_res:
    #     user_res[i] = user_res[i]/emax

    item_res = {}
    for user in user_res.keys():
        energy = user_res[user]
        #        print(energy)
        k_user = len(G[user])  # 用户的度
        G_sim_n = G_sim[target][user]  # 目标用户和其他用户之间的相似性

        for item in G[user]:
            pre_value = item_res.setdefault(item, 0)
            # G_sim_n：目标用户和其他用户之间的相似性，nk2是参数，调节相似度对推荐的影响
            item_res[item] = (G_sim_n ** nk2 * energy / float(k_user) + pre_value)

    for item in G[user]:
        item_res[item] = 0.0

    # 返回能量最多的前n个物品推荐给目标用户
    return sorted(item_res.items(), key=lambda x: x[1], reverse=True)[0:N]  # ,user_res


def calc_recall(train, test, N, G, G_sim, nk1, nk2):
    """
    计算召回率
    召回率描述有多少比例的用户—物品评分记录包含在最终的推荐列表中
    通过【准确率/召回率】评测推荐算法的精度
    :param train:训练集
    :param test:测试集
    :param N:TopN推荐中N数目
    :return:召回率
    """
    hit = 0  # 预测准确的数目
    total = 0  # 所有行为总数
    count = 1
    for user in train.keys():
        print('\r用户{}'.format(count), end='')
        count += 1
        # 用户u在测试集上喜欢的物品集合为T(u)
        tu = test.get(user, {})
        # 对用户u推荐N个物品（记为R(u)）
        rank = recommend(G, user, N, G_sim, nk1, nk2)
        for item, _ in rank:
            item_id = int(item.split('_')[1])
            if item_id in tu:
                hit += 1
        total += len(tu)
    return hit / (total * 1.0)


def calc_precision(train, test, N, G, G_sim, nk1, nk2):
    """
    计算准确率
    准确率描述最终的推荐列表中有多少比例是发生过的用户—物品评分记录
    :param train:训练集
    :param test:测试集
    :param N:TopN推荐中N数目
    :return:准确率
    """
    hit = 0  # 预测准确的数目
    total = 0  # 所有行为总数
    for user in train.keys():
        # 用户u在测试集上喜欢的物品集合为T(u)
        tu = test.get(user, {})
        # 对用户u推荐N个物品（记为R(u)）
        rank = recommend(G, user, N, G_sim, nk1, nk2)
        for item, _ in rank:
            item_id = int(item.split('_')[1])
            if item_id in tu:
                hit += 1
        total += N
    return hit / (total * 1.0)


def calc_coverage(train, N, G):
    """
    计算覆盖率
    覆盖率反映了推荐算法发掘长尾的能力，覆盖率越高，说明推荐算法越能够将长尾中的物品推荐给用户
    :param train:训练集 字典user->items
    :param N: topN推荐中N
    :param K:
    :param W:
    """
    recommend_items = set()
    all_items = set()
    for user in train.keys():
        for item in train[user].keys():
            all_items.add(item)
        rank = recommend(G, user, N)
        for item, _ in rank:
            item_id = int(item.split('_')[1])
            recommend_items.add(item_id)
    return len(recommend_items) / (len(all_items) * 1.0)


def calc_popularity(train, N, G):
    """
    计算平均流行度 信息的熵
    如果推荐出的物品都很热门，说明推荐的新颖度较低，否则说明推荐结果比较新颖
    :param train:训练集 字典user->items
    :param N:topN推荐中N
    :return:平均流行度
    """
    item_popularity = dict()
    for user, items in train.items():
        for item in items.keys():
            if item not in item_popularity:
                item_popularity.setdefault(item, 0)
            item_popularity[item] += 1
    ret = 0
    n = 0
    for user in train.keys():
        rank = recommend(G, user, N)
        for item, _ in rank:
            item_id = int(item.split('_')[1])
            # 流行度分布满足长尾分布，在取对数后，流行度的平均值更加稳定
            ret += math.log(1 + item_popularity[item_id])
            n += 1
    return ret / (n * 1.0)


def calc_accuracy(train, test, N, G):
    hit = 0
    recall_total = 0
    precision_total = 0
    for user in train.keys():
        # 用户u在测试集上喜欢的物品集合为T(u)
        tu = test.get(user, {})
        # 对用户u推荐N个物品（记为R(u)）
        rank = recommend(G, user, N)
        for item, _ in rank:
            item_id = int(item.split('_')[1])
            if item_id in tu:
                hit += 1
        recall_total += len(tu)
        precision_total += N
    recall = hit / recall_total * 1.0
    precision = hit / precision_total * 1.0
    # return 2 * recall * precision / (recall + precision)
    return precision / recall


if __name__ == "__main__":
    time_1 = time.time()
    user = 1
    N = 50  # 推荐列表长度
    M = 4
    k = 2
    seed = 1
    print('读数据→', end='')
    data = get_data("../data/ratings.txt")
    train, test = split_data(data, M, k, seed)
    print('生成字典→', end='')
    G = gen_graph(train)
    #     此处为了方便，将G_sim弄成了全局的，这里也要先得到用户物品字典G才能运行。
    # 用户相似度矩阵
    G_sim = {}
    for user1 in G:  # use1遍历G里的用户
        if type(user1) == int:  # 判断是不是用户
            G_sim[user1] = {}
            #        G_sim[user1][user1]=1
            for user2 in G:  # use2遍历G里的用户
                if type(user2) == int:
                    if user2 in G_sim:
                        if user1 in G_sim[user2]:
                            G_sim[user1][user2] = G_sim[user2][user1]  # 这里跳过了和自己的比较
                    else:
                        G_sim[user1][user2] = sim(G[user1], G[user2])  # 计算两个用户之间的相似性
    #     对用户相似度进行归一化
    for u1 in G_sim:
        G_sim[u1][u1] = 0
        u_max = max([G_sim[u1][u2] for u2 in G_sim[u1]])
        for u2 in G_sim[u1]:
            G_sim[u1][u2] = G_sim[u1][u2] / u_max
        G_sim[u1][u1] = 1  # 用户自己和自己的相似度为1

    print('一次推荐→', end='')
    #    rank = recommend(G, user, N)
    time_2 = time.time()
    n_list = [0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1]  # 训练参数，参数的值
    prob_recall = []  # prob召回率
    for nk2 in n_list:
        print('计算召回率→')
        precall = calc_recall(train, test, 50, G, G_sim, 0.01, nk2)
        print('\n', nk2, ':', precall)
        prob_recall.append(precall)
    x = n_list
    plt.rcParams['font.sans-serif'] = ['SimHei']
    plt.plot(x, prob_recall, label='Prob', linewidth=1, color='r', marker='^', markerfacecolor='m', markersize=7)
    plt.xlabel('L')
    plt.ylabel('Recall Rate')
    plt.title('召回率折线图')
    plt.legend()
    plt.show()
#    n_recall=calc_recall(train, test, 50, G)
#    time_3 = time.time()
#    print('召回率：', n_recall, '用时%.2fs' % (time_3 - time_1), '计算精确率→', end='')
#    n_prec = calc_precision(train, test, N, G)  # 精确率
#    time_4 = time.time()
#    print('召回率{} 精确率 {} '.format(n_recall, n_prec), '用时%.2fs' % (time_4 - time_1))
